/*
  This file is part of JCM and is an enhancement of:

      http://blogs.sun.com/andreas/resource/InstallCert.java

  JCM is free software: you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  JCM is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with JCM.  If not, see <http://www.gnu.org/licenses/>.
*/
/*
 * Copyright 2006 Sun Microsystems, Inc.  All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *   - Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *
 *   - Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *
 *   - Neither the name of Sun Microsystems nor the names of its
 *     contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
import java.io.*;
import java.util.*;
import java.security.*;
import java.security.cert.*;
import java.net.*;
import javax.security.auth.x500.X500Principal;
import javax.net.ssl.*;

/**
 * Instalador de certificados de autoridades do JSSE (Java Secure Socket Extension)
 * que atualiza ou cria o respectivo arquivo num diretório preferencial declarado
 * na propriedade do sistema "javax.net.ssl.trustStore" ou no diretório default dos
 * arquivos de segurança do JRE.
 * Os certificados são obtidos mediante reconhecimento entre as partes sob
 * responsabilidade do usuário que aceita o dominio cedente como confiável.
*/
public class CAInstaller
{
  /** Arquivo de certificados do JSSE. */
  private File file;

  /** Instância única do keystore. */
  private KeyStore ks;

  /** Senha efetiva de acesso ao arquivo do keystore. */
  private char[] password;

  /**
   * Construtor visando instância única de objeto da classe.
   * Carrega o keystore de arquivo de certificados do JSSE ou do arquivo de
   * certificados padrão do JRE.
   *
   * @param passwd Senha de acesso ao arquivo do keystore.
   * @throws Exception Qualquer excessão gerada na carga do keystore.
  */
  protected CAInstaller(String passwd) throws Exception
  {
    // diretório default dos arquivos de certificados de autoridade
    String path = System.getProperty("java.home") + File.separatorChar + "lib" + File.separatorChar + "security";

    // caminho completo p/arquivo de certificados de autoridades
    String trustStore = System.getProperty("javax.net.ssl.trustStore");

    file = new File(
      (trustStore != null) ? trustStore : path + File.separatorChar + "jssecacerts");

    // prepara o arquivo de certificados p/leitura conforme disponibilidade
    InputStream in = new FileInputStream(
      file.isFile() ? file : new File(path, "cacerts"));

    assert in != null : "Não encontrou arquivo de certificados.";

    ks = KeyStore.getInstance(KeyStore.getDefaultType());

    password = passwd.toCharArray();

    // carrega o keystore do arquivo de certificados
    ks.load(in, password);
    in.close();
  }

  /** Ponteiro para instância única do objeto. */
  private static CAInstaller installer;

  /**
   * Acessor da instância única de objeto desta classe com instanciamento de
   * keystore carregado de arquivo de certificados acessado com senha fornecida
   * ou com a senha default se a senha fornecida for null.
   *
   * @param passwd Senha de acesso ao arquivo de certificados do keystore.
   * @return Instância única de objeto dessa classe.
   * @throws Exception Qualquer excessão gerada durante o instanciamento.
  */
  public static CAInstaller getInstance(String passwd) throws Exception
  {
    if (installer == null) {
      installer = new CAInstaller(passwd == null ? "changeit" : passwd);
    }

    return installer;
  }

  /**
   * Acessor da instância única de objeto desta classe com instanciamento de
   * keystore carregado de arquivo de certificados acessado com senha default.
   *
   * @return Instancia única de instalador.
   * @throws Exception Qualquer excessão gerada durante o instanciamento.
  */
  public static CAInstaller getInstance() throws Exception
  {
    return getInstance(null);
  }

  /** Ponteiro para o certificado acessado mais recentemente. */
  private X509Certificate cert;

  /**
   * Checa se o keystore contém o alias fornecido com certificado associado válido.
   *
   * @param alias String contendo o alias objeto da pesquisa.
   * @return Status lógico da pesquisa.
   * @throws Exception Excessão ao acessar o keystore.
  */
  public boolean check(String alias) throws Exception
  {
    cert = null;  // reinicia o ponteiro

    // checa se o alias já foi cadastrado no keystore
    boolean valid = ks.containsAlias(alias);
    if (valid) {
      // obtém o certificado associado
      cert = (X509Certificate) ks.getCertificate(alias);
      // checa se existe certificado
      if (valid = (cert != null)) {
        // confirma a validade do certificado conforme excessão gerada
        try {
          cert.checkValidity();
        } catch (Exception e) {
          // o certificado expirou ou ainda não é válido
          valid = false;
        }
      }
    }
    return valid;
  }

  /**
   * Instala certificado de autoridade obtido junto ao host e na porta de acesso
   * fornecidos, associado ao alias, com posterior atualização ou criação do arquivo
   * de certificados.
   *
   * @param alias Alias opcional associado a autoridade certificada. Se for "null"
   *        será assumido o "domain name" do host como valor default.
   * @param host Componente dominio da url da autoridade.
   * @param port Número da porta de acesso no dominio.
   * @throws Exception Qualquer excessão que impossibilite a instalação do certificado.
  */
  public void install(String alias, String host, int port) throws Exception
  {
    // checa o número da porta de acesso no host
    if (port < 0 || port > 65535) port = 443;

    cert = null;  // reinicia o ponteiro antecipando possível excessão

    // cria socket ordinário com possível UnknownHostException
    InetAddress addr = InetAddress.getByName(host);
    SocketAddress sockaddr = new InetSocketAddress(addr, port);
    Socket socket = new Socket();
    // conecta ao host:port com possível TimeOutException
    socket.connect(sockaddr, 10000);

    // monta o engine de análise dos certificados
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(
      TrustManagerFactory.getDefaultAlgorithm());

    tmf.init(ks);

    X509TrustManager defTrustManager = (X509TrustManager) tmf.getTrustManagers()[0];

    SavingTrustManager tm = new SavingTrustManager(defTrustManager);

    // instancia um contexto SSL sob protocolo TLS (Transport Layer Security)
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, new TrustManager[] {tm}, null);

    // cria o Secure Socket Layer de facto envolvendo o socket ordinário
    SSLSocketFactory factory = context.getSocketFactory();
    SSLSocket sslsocket = (SSLSocket) factory.createSocket(socket, host, port, true);
    try {
      // reconhecimento SSL entre as partes
      sslsocket.startHandshake();
      sslsocket.close();
    } catch (SSLException e) {
      // ignora erro se certificado foi previamente cadastrado
      // System.err.println("Handshake error: " + e);
    }

    // checa a sequência de certificados enviadas pelo server no "handshake"
    if (tm.chain == null) {
      throw new Exception("Não obteve certificados do servidor.");
    }

    // loop de busca pelo certificado cujo CN (Common Name) é igual
    // ou compatível com hostname usando wildcards
    //
    // >> PRATICA NÃO RECOMENDADA: usar wildcards no CN
    // >> http://nils.toedtmann.net/pub/subjectAltName.txt
    //
    for (int j=0; (cert == null) && (j < tm.chain.length); j++)
    {
      X500Principal x = tm.chain[j].getSubjectX500Principal();
      String cn = peek(x, "CN")[0];
      // checa se CN não é igual ao hostname e contém ao menos um wildcard
      if (!cn.equals(host) && (cn.indexOf('*') >= 0)) {
        // monta expressão regular substituindo wildcards e separadores
        String re = cn.replaceAll("\\*", "[\\\\p{Alnum}-]*").replaceAll("\\.", "\\\\.");
        if (!host.matches(re)) continue;
      }
      cert = tm.chain[j]; // sucesso!
    }

    assert cert != null;

    // atualiza o keystore adicionando o par alias/certificado
    // usando o "domain name" como alias caso não declarado
    ks.setCertificateEntry(((alias != null) ? alias : host), cert);

    // armazena o keystore no arquivo de certificados do JSSE
    OutputStream out = new FileOutputStream(file);

    assert out != null;

    ks.store(out, password);
    out.flush();
    out.close();
  }

  /**
   * Cria texto com informações extraídas do certificado acessado num check-up
   * ou instalação prévia.
   *
   * @return String contendo o texto ou "null" se o certificado não existe.
  */
  public String certResume()
  {
    if (cert == null) return null;

    StringBuilder sb = new StringBuilder();

    X500Principal x = cert.getSubjectX500Principal();

    sb.append("Emitido para:").append(LF);
    sb.append("                   nome: ").append(mkTxt(x, "CN", 25));
    sb.append(" unidade organizacional: ").append(mkTxt(x, "OU", 25));
    sb.append("            organização: ").append(mkTxt(x, "O", 25));
    sb.append("                   país: ").append(mkTxt(x, "C", 25)).append(LF);

    x = cert.getIssuerX500Principal();

    sb.append("Emitido por:").append(LF);
    sb.append("                   nome: ").append(mkTxt(x, "CN", 25));
    sb.append(" unidade organizacional: ").append(mkTxt(x, "OU", 25));
    sb.append("            organização: ").append(mkTxt(x, "O", 25));
    sb.append("                   país: ").append(mkTxt(x, "C", 25)).append(LF);

    sb.append("               Validade: ");
    sb.append(String.format("%1$ta %1$td/%1$tb/%1$tY %1$tR", cert.getNotBefore()));
    sb.append(" a ");
    sb.append(String.format("%1$ta %1$td/%1$tb/%1$tY %1$tR", cert.getNotAfter()));
    sb.append(LF).append(LF);

    sb.append("        Número de Série: ").append(cert.getSerialNumber()).append(LF);
    sb.append("                 Versão: ").append(cert.getVersion()).append(LF);
    sb.append("              Algoritmo: ").append(cert.getSigAlgName()).append(LF);

    return sb.toString();
  }

  /**
   * Compõe texto onde cada linha contém um dos múltiplos valores atribuidos
   * à chave fornecida num componente de certificado X509.
   *
   * @param x Componente de certificado X509.
   * @param key Chave do atributo visado.
   * @param padLen Comprimento da indentação em múltiplas linhas.
   * @return String contendo o texto com uma ou mais linhas.
  */
  public String mkTxt(X500Principal x, String key, int padLen)
  {
    StringBuilder sb = new StringBuilder();
    String indentation = String.format("%1$" + padLen + "c", ' ');
    int n = 0;
    for (String value: peek(x, key))
    {
      if (n++ > 0) sb.append(indentation);
      sb.append(value).append(LF);
    }
    return (n > 0) ? sb.toString() : LF;
  }

  /**
   * Extrai todos os valores atribuidos à chave fornecida num componente de
   * certificado X509.
   *
   * @param x Componente de certificado X509.
   * @param key Chave do atributo visado.
   * @return String contendo o texto com uma ou mais linhas.
  */
  public String[] peek(X500Principal x, String key)
  {
    ArrayList<String> list = new ArrayList<String>();
    // prefixo de pesquisa
    String prefix = String.format("%s=", key);
    int pos = prefix.length();
    // loop de seleção dos múltiplos valores do componente
    for (String pair: x.getName().split("[,;]"))
    {
      if (pair.startsWith(prefix)) list.add( pair.substring(pos) );
    }
    return list.toArray(new String[list.size()]);
  }

  /**
   * Instalação de certificados via linha de comando.
   *
   * <code>Uso: java CAInstaller &lt;dominio&gt;[:porta] [senha]
   *
   *   dominio Dominio a autorizar acesso.
   *     porta Porta de acesso do dominio. (opcional)
   *     senha Senha de acesso ao arquivo de certificados do JSSE. (opcional)
   * </code>
   *
   * @param args
  */
  public static void main(String[] args)
  {
    if (args.length != 1 && args.length != 2) {
      System.out.println();
      System.out.println("Uso: java CAInstaller <dominio>[:porta] [senha]");
      System.out.println();
      System.out.println("  dominio Dominio a autorizar acesso.");
      System.out.println("    porta Porta de acesso do dominio. (opcional)");
      System.out.println("    senha Senha de acesso ao arquivo de certificados do JSSE. (opcional)");
      System.out.println();
      System.exit(0);
    }
    String[] pair = args[0].split(":");

    String domain = pair[0];

    int port = (pair.length == 2) ? Integer.parseInt(pair[1]) : 443;

    String passwd = (args.length == 2) ? args[1] : null;

    try {
      CAInstaller instalador = CAInstaller.getInstance(passwd);

      assert instalador != null;

      String alias = domain; // simplificação

      if (instalador.check(alias)) {
        System.out.format("%nCertificado de \"%s\" é válido.%n%n", alias);
      } else {
        instalador.install(alias, domain, port);
        System.out.format("%nCertificado de \"%s\" instalado.%n%n", alias);
      }
      System.out.println(instalador.certResume());

    } catch (FileNotFoundException e) {
      if (e.getMessage().contains("Permission denied"))
      {
        if (System.getProperty("os.name").equals("Linux")) {
          System.err.format("%nErro: Permissão de escrita negada para o usuário \"%s\".%n%n", System.getProperty("user.name"));
        } else {
          System.err.format("%nErro: Permissão de escrita negada para o usuário corrente.%n%n");
        }
      }
      else System.err.println(e);
    } catch (UnknownHostException e) {
      System.err.format("%nErro: Host \"%s\" é deconhecido.%n%n", args[0]);
    } catch (SocketTimeoutException e) {
      System.err.format("%nErro: Tempo esgotado na tentativa de acesso a \"%s\".%n%n", args[0]);
    } catch (ConnectException e) {
      System.err.format("%nErro: Conexão recusada ao host \"%s\".%n%n", args[0]);
    } catch (Exception e) {
      //System.err.println("Unknown Error: " + e);
      e.printStackTrace();
    }
  }

  /**
   * Implementação de gerenciador de análise de certificados X509.
  */
  private class SavingTrustManager implements X509TrustManager
  {
    private final X509TrustManager tm;

    private X509Certificate[] chain;

    SavingTrustManager(X509TrustManager tm)
    {
      this.tm = tm;
    }

    public X509Certificate[] getAcceptedIssuers()
    {
      throw new UnsupportedOperationException();
    }

    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
    {
      throw new UnsupportedOperationException();
    }

    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
    {
      this.chain = chain;
      tm.checkServerTrusted(chain, authType);
    }
  }

  private final String LF = System.getProperty("line.separator");
}
